Go 语言编程 — GPM 与 CSP 高并发模型

目录

GPM 调度模型

Golang Runtime 实现的 GMP 调度模型是在 Linux 两级线程调度模型(User Thread 和 LWP 混合的多对多模型)的基础上进行了改进,对 Linux LWP 和 Kernel Thread 进行了封装并引入了 G(Goroutine)、P(Processor)、M(Machine)、Golang Runtime Scheduler 等新的对象。

GPM 的本质是一种协作式的并发模型,它使用了特有的调度机制和栈切换机制,能够高效地并发执行多个任务,避免了线程阻塞和上下文切换的开销。

基本概念

G(Goroutine)

Goroutine 的本质是 Golang Runtime 抽象实现的一种函数实例,Goroutine 结构体具有自己的函数体、堆栈、执行状态、寄存器上下文和程序计数器等信息。

同时,Golang Runtime 还为 Goroutine 糅合了 “User Level Thread 线程调度" 和 “Co-routine 栈切换” 这两大机制:

  1. User Level Thread 线程调度:是一种用户态线程,由 User Process 负责创建和销毁,所有 Goroutine 实例共享同一个 User Process 的内存空间。多个 Goroutine 实例之间的执行采用了 M:N 两级线程调度模型。
  2. Co-routine 栈切换:具有 “主动让出、保存自身的状态、等候恢复“ 特性,这是一种 “主动的协作式" 调度机制。在 Goroutine 实例中可以设置某个 “主动让出“ 点(类似 Python 的 yield 语句),从而释放 Machine 去执行其他 Goroutine 实例。这一 Co-routine 特性只需要实现 “栈切换“,相对于 “线程切换“ 而言,有更快的速度和更小的开销。

可见,Goroutine 兼具了 User Level Thread 和 Co-routine 的特点,并与 GPM 的整体架构自洽,Goroutine 实例只有被存储到 Local Goroutine Queue 或 Global Goroutine Queue 时才会被调度。

Golang 原生支持高并发,使用 go 语句即可新建一个 Goroutine 实例。

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    // go 函数名(形参列表)
    go say("world")
    say("hello")
}

P(Processor)

Processor 的本质是 Golang Runtime 抽象实现的一种 Goroutine Queue 和处理器执行环境的结合体。

  1. 一方面,以 Local Goroutine Queue 的方式来存储待执行 Goroutine 实例,类似于传统 Linux 的线程调度队列,与 Scheduler 配合将 Goroutine 调度到相应的 Machine 中执行;
  2. 另一方面,对 Machine 来说,Processor 结构体提供了相关的处理器执行环境信息,包括:内存分配状态,任务队列等;对于 Goroutine 来说,Machine 和 Processor 共同构成了 Goroutine 的执行环境。

Processor 的 Local Goroutine Queue 与 Global Goroutine Queue 的区别在于:Local Queue 有长度限制,不超过 256 个。新建 Goroutine 时,会优先选择 Local Queue,如果 Local Queue 满了,则将 Local Queue 的一半的 G 移动到 Global Queue,以此来实现调度资源的共享和再平衡。

M(Machine)

Machine 的本质是对 Kernel Thread 的封装,作为真正的可调度执行单元。Machine 首先会与 Processor 建立关联,然后不断地从 Local Goroutine Queue 或 Global Goroutine Queue 中获取 Goroutine 实例并调度到 CPU core 中执行。

当 Processor 被入队 Goroutine 实例时,就会触发创建或者唤醒一个 Machine 去执行。并且,每个 Machine 在同一时刻只能执行一个 Goroutine 实例,但是多个 Goroutine 实例可以在多个 Machine 上并发地执行。

Golang Runtime Scheduler

Scheduler 本质是 Golang Runtime 抽象实现的一种 Goroutine 调度器,类似 Linux Kernel 种的线程调度器。

当正在 Machine 中执行的某个 Goroutine 实例阻塞时,Scheduler 会将该 Machine 分配给同一 Queue 中的其他 Goroutine 实例,从而避免了某个 Goroutine 阻塞导致的 Machine 资源浪费。

M:N 两级线程调度模型

在这里插入图片描述

G、P、M 三者组合实现了 Golang 特别的 M:N 两级线程调度模型,其中包含了以下细节:

  • 映射关系
    • G、P 是多对多映射关系;
    • P、M 是一对一映射关系;
  • 绑定关系
    • P、M 之间并没有绑定关系
    • M、Kernel Thread 之间存在绑定关系。
  • 数量关系
    • P 的数量是固定的,由物理环境决定的,可以通过修改环境变量 GOMAXPROCS 来设置,要小于等于 Linux Processor(逻辑处理器)的数量。
    • M 的数量不受限于 Linux Processor 数量的限制,如 Kernel Thread 一般。当没有足够的 M 来执行 G 时,Runtime 就会自动创建出新的 M;
  • 两级调度
    • 一级调度:G 到 P 的调度。
    • 二级调度:M 到 CPU 的调度。
  • M:N(多对多):G、M 之间是多对多调度关系。

P-M 分离

M 在执行 G 时必须映射到一个 P,没有映射到 P 的 M 处于空闲状态。P、M 分离增加了架构的扩展性,为了保证 P 中的 G 能够得到及时执行。如下图所示:

  1. 当 M 被阻塞时,M 就会释放 P,然后将 P 映射到空闲的 M 上。例如:当 G0 此时因为网络 I/O 而阻塞了 M,那么 P 就会携带剩余的 G 映射到 M1 中。M1 可能是新创建的,也可能是 Scheduler 从空闲 M 列表中分配的。

  2. 当 M 对应的 Kernel Thread 被唤醒时,M 将会尝试为 G0 捕获一个 P 上下文。此时 M 会从 Schduler 的空闲 P 列表中获取,如果获取不成功,M 会被 G0 放入到 Schduler 的可执行 G 队列中,等待其他非空闲 P 的查找。

在这里插入图片描述

G-M 绑定

通常的,G-M 是分离解耦的状态。特别的 G-M 绑定功能,专用于某些要求固定在一个线程上运行的程序,需要通过 lockOSThread 和 unlockOSThread 来实现。

处理流程如下:

  1. G_a 锁定 M0 lockOSThread。
  2. G_a 调用 gosched 切走,投入 P1 队列。
  3. M0 调度,发现是 lockedm,于是让出 P0,自己调用 notesleep 睡眠。
  4. M1 取出 G_a,发现是 lockedg,于是让出 P1 给 M0,并且唤醒 M0,自己变 idle,stopm 休眠。
  5. M0 继续执行 G_a。

最终效果是,G_a 只在 M0 上运行,锁定这段期间,M0 也只执行了 G_a 的任务。

Scheduler Workflow

Goroutine 实例在 GPM 调度模型中处理流程如下所示:

  1. 当 Golang Runtime 执行 go func() 语句时,新建一个 G 实例。
  2. 新建的 G 会被放入 P 的 Local Queue 或 Global Queue 中,进入等待执行的状态。
  3. P 唤醒或创建 M 以执行 G。
  4. M 不断地进行事件循环,寻找在可用状态下的 G 并执行其任务(func)。
  5. M 执行完 G 并清除
  6. 清除后,M 重新进入事件循环。

在这里插入图片描述

Steal(任务窃取)

Steal(任务窃取)的作用是为了保证 G 的均衡执行。当 M 执行 G 完毕后,P 会将 G 从 Local Queue 中弹出,同时 P 会检查当前的 Local Queue 是否为空。如果为空,则会先从 Global Queue 窃取 G,如果没有获取到,然后再考虑随机地从其他 P 的 Local Queue 中尝试窃取一半可运行的 G。

如下图所示,P2 在 Local Queue 中找不到可以运行的 G,它就会执行 work-stealing 调度算法,随机选择其它的 P,例如 P1,并从 P1 的 Local Queue 中窃取了三个 G(一半)到 P2 自己的 Local Queue 中。至此,P1、P2 都拥有了可运行的 G,P1 多余的 G 也不会被浪费,调度资源将会更加平均的在多个处理器中流转。

在这里插入图片描述

Sysmon(系统监控)

Sysmon(系统监控)是一个特殊的 M,但 sysmon 不会映射到 P,只作用于监控一些阻塞的异常情况,比如有一个 M 长时间阻塞超过 10ms,那么 sysmon 会强制把 M-P 解映射,把 M 游离出去,同时让 P 映射到一个新的空闲 M 上,继续执行队列里的 G 任务。

CSP 并发模型

CSP(Communicating Sequential Processes,通信顺序进程)并发模型最早在 1977 年由 Tony Hoare 发表的论文提出,它倡导使用通信的手段来进行共享内存,继而实现多个线程之间的通信。这也是 Golang 倡导使用的并发模型,通过 Channel 来使用。

CSP 有两个核心概念:

  1. 并发实体:在 Golang 中就是 Goroutine,它们相互独立,且并发执行;
  2. 通道(Channel):并发实体之间使用 Channel 发送信息。

Golang 实现的 CSP 并发模型最大的特征就是 Goroutine 之间没有使用共享的内存空间,而是使用 Channel 来进行数据交换,传输具有类型的消息。并发实体在通道中发送数据或接受数据都会让 Goroutine 的阻塞,直到 Channel 中的数据被发送或接受完成。Goroutine 之间通过这种方式实现交互及同步。

可见,CSP 类似于同步队列(会阻塞),关注的是消息传输的方式,发送和接收信息的 Goroutine 可能不知道对方是谁,它们之间是互相解耦的。另外,Channel 与 Goroutine 也不是紧耦合的,Channel 作为独立的对象,可以被任意的创建、释放、读取、放入数据,并在不同的 Goroutine 中传递使用。

Channel 的特性给并发编程带来了极大的灵活性,但是 Channel 也很容易导致死锁,如果一个 Goroutine 在读取一个永远没有数据放入的 Channel 或者把数据放入一个永远不会被读取的 Channel 中,那么它会将被永远阻塞。

在这里插入图片描述

Channel 类型与操作符

Channel 的本质是一种用来传递数据的数据结构,在两个 Goroutine 之间传递一个具有特定类型的数据,以此来同步运行及通讯。

Channel 使用操作符 <-,形象的指定了通道的方向,根据位置的不同表示发送或接收。如果未指定方向,则表示为双向通道。例如:

ch <- v    // 把 v 发送到通道 ch
v := <-ch  // 从 ch 接收数据并把值赋给 v

使用 chan 关键字来定义一个 Channel 变量:

ch := make(chan int)

需要注意的是,默认情况下,Channel 是不自带缓冲区的。发送端发送数据,就必须同时存在接收端接收相应的数据。

  1. 示例 1:通过两个 Goroutine 来计算数字之和,在完成计算任务后,通过 Channel 来传输结果:
package main

import "fmt"

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c<- sum    // 把 sum 发送到通道 c
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    
    go sum(s[:(len(s) / 2)], c)
    go sum(s[(len(s) / 2):], c)
    x, y := <-c, <-c    // 从通道 c 中接收
    
    fmt.Println(x, y, x+y)
}
  1. 示例 2:生产者每秒生成一个字符串,并通过 Channel 传输给消费者,生产者使用两个 Goroutine 并发运行,消费者在 main() 函数的 Goroutine 中进行处理。
package main

import (
        "fmt"
        "math/rand"
        "time"
)

// 数据生产者
func producer(header string, channel chan<- string) {

     // 无限循环, 不停地生产数据
     for {

            // 将随机数和字符串格式化为字符串发送给通道
            channel <- fmt.Sprintf("%s: %v", header, rand.Int31())

            // 等待1秒
            time.Sleep(time.Second)
        }
}

// 数据消费者
func customer(channel <-chan string) {

     // 不停地获取数据
     for {

            // 从通道中取出数据, 此处会阻塞直到信道中返回数据
            message := <-channel

            // 打印数据
            fmt.Println(message)
        }
}

func main() {

    // 创建一个字符串类型的通道
    channel := make(chan string)

    // 创建producer()函数的并发goroutine
    go producer("cat", channel)
    go producer("dog", channel)

    // 数据消费函数
    customer(channel)
}

运行结果:

dog: 2019727887
cat: 1298498081
dog: 939984059
cat: 1427131847
cat: 911902081
dog: 1474941318
dog: 140954425
cat: 336122540
cat: 208240456
dog: 646203300

整段代码中,没有线程创建,没有线程池也没有加锁,仅仅通过关键字 “go” 实现 goroutine,和 channel 实现数据交换。

Channel 缓冲区

Channel 可以显式地设置缓冲区,缓冲区就类似于一个消息队列。带缓冲区的 Channel 允许发送端的数据发送,和接收端的数据接收处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,然后等待接收端去获取数据,而不是要求接收端立刻去获取数据。

如果 Channel 不设置缓冲区,那么发送端就会阻塞,直到接收端从 Channel 中接收了值。如果 Channel 带缓冲,那么发送端则会阻塞,直到发送的值被拷贝到缓冲区内。如果 Channel 的缓冲区已满,则意味着需要等待直到某个接收端获取到一个值。接收端在有值可以接收之前会一直阻塞。

使用 make() 的第二个参数来指定缓冲区大小,但需要注意的是,缓冲区的大小是有限的,所以还是必须要有接收端来接收数据,否则缓冲区一满,数据发送端就无法再发送数据了。

ch := make(chan int, 100)

示例:

package main

import "fmt"

func main() {
    // 这里我们定义了一个可以存储整数类型的带缓冲通道,缓冲区大小为 2。
    ch := make(chan int, 2)

    // 因为 ch 是带缓冲的通道,我们可以同时发送两个数据,而不用立刻需要去同步读取数据。
    ch <- 1
    ch <- 2

    // 获取这两个数据
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

Channel 遍历与关闭

通过 range 关键字还可以用于遍历 Channel 变量,以遍历的方式来读取数据。如果 Channel 接收不到数据,那么 ok 变量为 false,这时 Channel 变量就可以使用 close() 函数来关闭。

package main

import "fmt"

func fibonacci(n int, c chan int) {
    x, y := 0, 1
    for i := 0; i < n; i++ {
        c <- x
        x, y = y, x + y
    }
    close(c)
}

func main() {
    c := make(chan int, 10)
    go fibonacci(cap(c), c)

    /**
     * range 函数遍历每个从通道接收到的数据,
     * 因为 c 在发送完 10 个数据之后就关闭了通道,
     * 所以这里我们 range 函数在接收到 10 个数据之后就结束了。
     * 如果上面的 c 通道不关闭,那么 range 函数就不会结束,从而在接收第 11 个数据的时候就阻塞了。
     */
    for i := range c {
        fmt.Println(i)
    }
}

锁并发模型

锁并发模型是高级编程语言最常规的多线程并发模型,依赖共享内存,程序的正确运行很大程度依赖于开发人员的能力和技巧,程序在出错时也不易排查。

Golang 除了 CSP 之外也同样支持协程锁,用于保证执行 Goroutine 的时候不阻塞 M。例如:任务 A 需要修改 Z,任务 B 也需要修改 Z。如果是串行系统,A 执行完了,再执行B,很简单。但在并发系统中,因为 A,B 是并发执行的,所以就需要在操作 Z 的时候确保 A、B 保证串行化的机制。

CO_LOCK
{
    // 处理逻辑
}
CO_UNLOCK

如下图所示:

  1. A 要修改 Z,所以 A 加了协程锁。
  2. 加锁之后,由于处理一些其他的逻辑,例如等待某些事件,又把 CPU 切到 M.g0 调度了(yield),并且此时没有放锁。
  3. 这时 M 把 B 拿过来执行,yield to B。
  4. B 也要修改 Z,但此时发现 A 已经对 Z 加锁了,于是 B 把自己挂到锁结构里面去。
  5. 然后 B 直接切走,yield to M.g0。
  6. 现在 A 的事件到达,M.g0 重新调度到 A 执行,yield to A。
  7. A 从刚刚切走的地方开始执行,完成后放锁。注意,A 方锁时,就会把 B 从锁队列中摘除,重新加到 M 的调度队列中。
  8. A 方锁后,M.g0 调度 B 执行。
  9. B 从刚刚加锁的地方唤醒,然后对 Z 加锁。然后走锁内逻辑后,放锁。

以上就是协程锁的实现原理。保证 A、B 在修改 Z 的时候必须串行化。

在这里插入图片描述

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

范桂飓

文章对您有帮助就请一键三连:)

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值